Цель исследования: на основе данных о новых пользователях, их активности, а также с учётом данных о маркетинговых активностях компании за 2020 год оценить результаты проведённого АВ-теста новой системы рекомендаций для пользователей.
Задачи исследования:
Техническое задание на АВ-тест:
recommender_system_test;product_page,product_cart,purchase.Описание имеющихся в наличии данных:
final_ab_new_users.csv — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года.Структура файла:
user_id — идентификатор пользователя;first_date — дата регистрации;region — регион пользователя;device — устройство, с которого происходила регистрация.final_ab_participants.csv — таблица участников тестов.Структура файла:
user_id — идентификатор пользователя;ab_test — название теста;group — группа пользователя.final_ab_events.csv — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.Структура файла:
user_id — идентификатор пользователя;event_dt — дата и время покупки;event_name — тип события;details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.ab_project_marketing_events.csv — календарь маркетинговых событий на 2020 год.Структура файла:
name — название маркетингового события;regions — регионы, в которых будет проводиться рекламная кампания;start_dt — дата начала кампании;finish_dt — дата завершения кампании.Примерный план исследования:
import os
# основные библиотеки DA
import pandas as pd
import numpy as np
import math as mth
# библиотеки работы с датой и временем
from datetime import timedelta
from datetime import datetime
# библиотеки визуализации
import plotly.express as px
from plotly import graph_objects as go
# статистические библиотеки
from scipy import stats as st
# определение функции загрузки данных
# ======================================================
# на вход подаётся:
# file_name - имя файла
# на выходе - датафрейм с загруженными данными
# в случае ошибки при загрузке файла бросается искоючение
# ValueError
# ======================================================
def open_file(file_name, sep=','):
pth1 = '/datasets/' + file_name # яндексовский путь
pth2 = os.path.join('datasets', file_name) # мой путь
if os.path.exists(pth1):
return pd.read_csv(pth1, sep=sep)
elif os.path.exists(pth2):
return pd.read_csv(pth2, sep=sep)
# ни один путь не доступен
raise ValueError("ERROR: Neither Yandex nor local path is reacable...")
# определение функции обзора данных
# ===============================================
# на вход подаётся датафрейм df
# на выходе:
# - 10 случайных строк df
# - информация df.info()
# - количество явных дубликатов в строках df
# - процент пропусков данных в столбцах df
# ===============================================
def data_observe(df):
row_num = 5 # количество отображаемых строк таблицы
print('Размерность данных (row, col):', df.shape)
print('============================\n')
print('Произвольные строки таблицы:')
print('============================')
if len(df) >= row_num:
display(df.sample(row_num))
else:
display(df)
print('\nИнформация о таблице:')
print('=====================')
df.info()
print('\nКоличество явных дубликатов в таблице:')
print('======================================')
print(df.duplicated().sum())
print('\nПроцент пропусков в столбцах:')
print('=============================')
display(pd.DataFrame(
round((df.isna().mean()*100),2), columns=['NaNs, %'])
.sort_values(by='NaNs, %', ascending=False
)
.style.format('{:.2f}')
.background_gradient('coolwarm')
)
final_ab_new_users.csv¶Откроем и изучим содержимое файла final_ab_new_users.csv:
try:
new_users = open_file('final_ab_new_users.csv', sep=',')
data_observe(new_users)
except ValueError as err:
print(err)
Размерность данных (row, col): (61733, 4) ============================ Произвольные строки таблицы: ============================
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 32400 | FB08394C20381E32 | 2020-12-23 | N.America | Mac |
| 36179 | 94780B0323120EE4 | 2020-12-10 | N.America | PC |
| 32676 | 74F16DCF9FE79A89 | 2020-12-23 | EU | Android |
| 30168 | 13781220341546B3 | 2020-12-16 | EU | Android |
| 21234 | 22B322EF0FED6148 | 2020-12-15 | EU | PC |
Информация о таблице: ===================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 61733 entries, 0 to 61732 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 61733 non-null object 1 first_date 61733 non-null object 2 region 61733 non-null object 3 device 61733 non-null object dtypes: object(4) memory usage: 1.9+ MB Количество явных дубликатов в таблице: ====================================== 0 Процент пропусков в столбцах: =============================
| NaNs, % | |
|---|---|
| user_id | 0.00 |
| first_date | 0.00 |
| region | 0.00 |
| device | 0.00 |
Итак, в таблице новых пользователей, зарегистрировавшихся с 7 по 21 декабря 2020 года, 61733 строки и 4 столбца. Столбцы поименованы в хорошем стиле snake_case, все имеют тип object.
В таблице отсутствуют явные дубликаты и пропуски.
Для дальнейшей работы столбец 'first_date' целесообразно привести к типу date.
final_ab_participants.csv¶Откроем и изучим содержимое файла final_ab_participants.csv:
try:
participants = open_file('final_ab_participants.csv', sep=',')
data_observe(participants)
except ValueError as err:
print(err)
Размерность данных (row, col): (18268, 3) ============================ Произвольные строки таблицы: ============================
| user_id | group | ab_test | |
|---|---|---|---|
| 11574 | 3EE718562551ECDD | B | interface_eu_test |
| 11317 | CA640B1407337D93 | B | interface_eu_test |
| 10881 | 5B6CAF9FF9207A0D | A | interface_eu_test |
| 3115 | 9D7EACF1BBB179BC | B | recommender_system_test |
| 10698 | 75654D851F8C8F59 | A | interface_eu_test |
Информация о таблице: ===================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 18268 entries, 0 to 18267 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 18268 non-null object 1 group 18268 non-null object 2 ab_test 18268 non-null object dtypes: object(3) memory usage: 428.3+ KB Количество явных дубликатов в таблице: ====================================== 0 Процент пропусков в столбцах: =============================
| NaNs, % | |
|---|---|
| user_id | 0.00 |
| group | 0.00 |
| ab_test | 0.00 |
В таблице участников АВ-тестов 18268 строк и 3 столбца. Столбцы поименованы в хорошем стиле snake_case, все имеют тип object.
В таблице отсутствуют явные дубликаты и пропуски.
Приведение типов не требуется.
final_ab_events.csv¶Откроем и изучим содержимое файла final_ab_events.csv:
try:
events = open_file('final_ab_events.csv', sep=',')
data_observe(events)
except ValueError as err:
print(err)
Размерность данных (row, col): (440317, 4) ============================ Произвольные строки таблицы: ============================
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 74147 | 06F6F2EC08558DC0 | 2020-12-12 15:43:52 | product_cart | NaN |
| 131856 | 7C14AF59F1C215C0 | 2020-12-08 09:06:49 | product_page | NaN |
| 436079 | CB649BD92A6AC43E | 2020-12-29 15:51:46 | login | NaN |
| 255937 | 39FB64068022CECD | 2020-12-08 10:08:55 | login | NaN |
| 42919 | 203773F2A53233A8 | 2020-12-22 17:47:33 | purchase | 4.99 |
Информация о таблице: ===================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 440317 entries, 0 to 440316 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 440317 non-null object 1 event_dt 440317 non-null object 2 event_name 440317 non-null object 3 details 62740 non-null float64 dtypes: float64(1), object(3) memory usage: 13.4+ MB Количество явных дубликатов в таблице: ====================================== 0 Процент пропусков в столбцах: =============================
| NaNs, % | |
|---|---|
| details | 85.75 |
| user_id | 0.00 |
| event_dt | 0.00 |
| event_name | 0.00 |
Итак, в логе действий новых пользователей в период с 7 декабря 2020 по 4 января 2021 года 440317 строк и 4 столбца. Столбцы поименованы в хорошем стиле snake_case, все, за исключением 'details' имеют тип object.
В таблице отсутствуют явные дубликаты.
Столбец 'details' имеет тип float64 и содержит 85.75% пропусков. Согласно описанию данных, это дополнительые данные о событии - необязательное поле. Заполнение пропусков не требуется.
Для дальнейшей работы столбец 'event_dt' целесообразно привести к типу datetime.
ab_project_marketing_events.csv¶Откроем и изучим содержимое файла ab_project_marketing_events.csv:
try:
marketing_events = open_file('ab_project_marketing_events.csv', sep=',')
data_observe(marketing_events)
except ValueError as err:
print(err)
Размерность данных (row, col): (14, 4) ============================ Произвольные строки таблицы: ============================
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 8 | International Women's Day Promo | EU, CIS, APAC | 2020-03-08 | 2020-03-10 |
| 7 | Labor day (May 1st) Ads Campaign | EU, CIS, APAC | 2020-05-01 | 2020-05-03 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
| 13 | Chinese Moon Festival | APAC | 2020-10-01 | 2020-10-07 |
| 9 | Victory Day CIS (May 9th) Event | CIS | 2020-05-09 | 2020-05-11 |
Информация о таблице: ===================== <class 'pandas.core.frame.DataFrame'> RangeIndex: 14 entries, 0 to 13 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 14 non-null object 1 regions 14 non-null object 2 start_dt 14 non-null object 3 finish_dt 14 non-null object dtypes: object(4) memory usage: 576.0+ bytes Количество явных дубликатов в таблице: ====================================== 0 Процент пропусков в столбцах: =============================
| NaNs, % | |
|---|---|
| name | 0.00 |
| regions | 0.00 |
| start_dt | 0.00 |
| finish_dt | 0.00 |
В календаре маркетинговых событий на 2020 год 14 строк и 4 столбца. Столбцы поименованы в хорошем стиле snake_case, все имеют тип object.
В таблице отсутствуют явные дубликаты и пропуски.
Для дальнейшей работы столбцы 'start_dt' и 'finish_dt' целесообразно привести к типу date.
Предварительно данные выглядят полными. В них отсутствуют явные дубликаты и пропуски данных в основных столбцах.
Столбец 'details' таблицы events содержит 85.75% пропусков. Согласно описанию данных, это дополнительые данные о событии - необязательное поле. Заполнение пропусков не требуется.
В таблицах new_users, events и marketing_events столбцы, отвечающие за дату и время, целесообразно привести к соответствующему типу - date и datetime.
На этапе обзора данных мы установили, что некоторые столбцы таблиц new_users, events и marketing_events целесообразно привести к типу date или datetime.
# приведём дату регистрации пользователей
new_users.first_date = pd.to_datetime(new_users.first_date)
print(new_users.first_date.dtype)
# приведём дату и время событий
events.event_dt = pd.to_datetime(events.event_dt)
print(events.event_dt.dtype)
# приведём дату и время начала и окончания маркетинговых событий
marketing_events.start_dt = pd.to_datetime(marketing_events.start_dt)
marketing_events.finish_dt = pd.to_datetime(marketing_events.finish_dt)
print(marketing_events.start_dt.dtype)
print(marketing_events.finish_dt.dtype)
datetime64[ns] datetime64[ns] datetime64[ns] datetime64[ns]
Типы даты и времени откорректированы во всех таблицах.
Перейдём к исследованию данных.
Посмотрим внимательнее на значения в таблице new_users:
print('Количество привлечённых уникальных пользователей:',
new_users.user_id.nunique())
print('Количество уникальных дней привлечения:',
new_users.first_date.nunique())
print('Дни, в которые приходили пользователи: с',
new_users.first_date.min(), 'по', new_users.first_date.max())
print('Количество уникальных регионов привлечения:',
new_users.region.nunique())
print('Список регионов:',
new_users.region.unique())
print('Количество уникальных названий устройств:',
new_users.device.nunique())
print('Список устройств:',
new_users.device.unique())
Количество привлечённых уникальных пользователей: 61733 Количество уникальных дней привлечения: 17 Дни, в которые приходили пользователи: с 2020-12-07 00:00:00 по 2020-12-23 00:00:00 Количество уникальных регионов привлечения: 4 Список регионов: ['EU' 'N.America' 'APAC' 'CIS'] Количество уникальных названий устройств: 4 Список устройств: ['PC' 'Android' 'iPhone' 'Mac']
Итак, за 17 дней (с 7 по 23 декабря 2020 года) из 4 регионов ('EU' 'N.America' 'APAC' 'CIS') пришли 61733 новых пользователей, регистрировавшихся с устройств 'PC' 'Android' 'iPhone' 'Mac'.
Количество уникальных привлечённых пользователей совпадает с общим количеством привлечённых пользователей, следовательно, нет неявных дубликатов и сбои в системе регистрации пользователей не зафиксированы.
Отметим, что данные расходятся с требованиями технического задания: привлечение пользователей должно было закончиться 21 декабря 2020 года.
Тем не менее, выделим целевой регион (EU) и снова оценим количество привлечённых пользователей и даты привлечения:
print('Количество привлечённых уникальных пользователей:',
new_users.query('region == "EU"').user_id.nunique())
print('Количество уникальных дней привлечения:',
new_users.query('region == "EU"').first_date.nunique())
print('Дни, в которые приходили пользователи: с',
new_users.first_date.min(), 'по', new_users.first_date.max())
print('Список регионов:',
new_users.query('region == "EU"').region.unique())
Количество привлечённых уникальных пользователей: 46270 Количество уникальных дней привлечения: 17 Дни, в которые приходили пользователи: с 2020-12-07 00:00:00 по 2020-12-23 00:00:00 Список регионов: ['EU']
Вниманию маркетологов: В целевом регионе АВ-теста набор новых пользователей длился дольше предписанного техническим заданием.
В дальнейшем для соответствия ТЗ необходимо будет отсечь пользователей, привлечённых 22 и 23 декабря.
Взглянем на значения в таблице participants:
print('Количество уникальных участников тестов:',
participants.user_id.nunique())
print('Количество уникальных групп:',
participants.group.nunique())
print('Список групп:',
participants.group.sort_values().unique())
print('Количество уникальных названий тестов:',
participants.ab_test.nunique())
print('Список тестов:',
participants.ab_test.sort_values().unique())
Количество уникальных участников тестов: 16666 Количество уникальных групп: 2 Список групп: ['A' 'B'] Количество уникальных названий тестов: 2 Список тестов: ['interface_eu_test' 'recommender_system_test']
Итак, в таблице зарегистрированы 16666 участников двух АВ-тестов (interface_eu_test, recommender_system_test), разделённых на контрольную и тестовую группы.
Для оценки тестов потребуется:
recommender_system_test участниками также и теста interface_eu_test;recommender_system_test;recommender_system_test требованиям технического задания (регион, % новых пользователей, ожидаемое количество участников).Изучим значения в таблице events:
print('Количество уникальных пользователей:',
events.user_id.nunique())
print('Количество уникальных названий событий:',
events.event_name.nunique())
print('Список названий событий:',
events.event_name.sort_values().unique())
print('Количество уникальных примечаний:',
events.details.nunique())
print('Список примечаний:',
events.details.sort_values().unique())
print('Начало логирования событий:',
events.event_dt.min())
print('Конец логирования событий:',
events.event_dt.max())
Количество уникальных пользователей: 58703 Количество уникальных названий событий: 4 Список названий событий: ['login' 'product_cart' 'product_page' 'purchase'] Количество уникальных примечаний: 4 Список примечаний: [ 4.99 9.99 99.99 499.99 nan] Начало логирования событий: 2020-12-07 00:00:33 Конец логирования событий: 2020-12-30 23:36:33
Итак, в таблице событий зарегистрированы действия 58703 пользователей.
Четыре зарегистрированных типа событий могут быть выстроены в логическую воронку: login -> product_page -> product_cart -> purchase.
Состав примечаний показывает, что пользователи делали покупки на 4.99, 9.99, 99.99, 499.99 долл.
События логировались с 7 по 30 декабря 2020 года включительно.
Проанализируем дату и время на дубликаты:
# посчитаем количество событий на каждую временную метку
event_dup = (
events
.groupby(by='event_dt')
.agg({'user_id':['count', 'nunique'], 'event_name':['count', 'nunique']})
)
event_dup.columns = ['user_count', 'user_unique', 'event_count', 'event_unique']
# удалим метки с единственным событием
event_dup = (
event_dup
.query('user_count > 1 or event_count > 1')
.sort_values(by=['user_count', 'event_count'], ascending=False)
)
event_dup
| user_count | user_unique | event_count | event_unique | |
|---|---|---|---|---|
| event_dt | ||||
| 2020-12-14 18:54:55 | 10 | 4 | 10 | 4 |
| 2020-12-23 02:37:24 | 10 | 3 | 10 | 4 |
| 2020-12-13 06:00:54 | 9 | 3 | 9 | 4 |
| 2020-12-20 02:51:18 | 9 | 3 | 9 | 4 |
| 2020-12-21 21:14:13 | 9 | 4 | 9 | 4 |
| ... | ... | ... | ... | ... |
| 2020-12-30 15:26:13 | 2 | 1 | 2 | 2 |
| 2020-12-30 16:47:56 | 2 | 1 | 2 | 2 |
| 2020-12-30 18:28:55 | 2 | 1 | 2 | 2 |
| 2020-12-30 20:41:37 | 2 | 1 | 2 | 2 |
| 2020-12-30 23:02:44 | 2 | 1 | 2 | 2 |
120353 rows × 4 columns
Итак, мы видим, что около 27% временных меток имеют дубликаты:
round(len(event_dup) * 100 / len(events), 2)
27.33
При этом, часть дубликатов соответствует разным пользователям (это нормальная ситуация), а часть - одному и тому же уникальному пользователю. Это может свидетельствовать о сбое в системе логирования. Взглянем на дубликаты:
events[events.event_dt.duplicated()].sort_values(by=['user_id', 'event_dt']).head(10)
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 157324 | 000199F1887AE5E6 | 2020-12-14 09:56:09 | product_page | NaN |
| 299212 | 000199F1887AE5E6 | 2020-12-14 09:56:09 | login | NaN |
| 81751 | 000199F1887AE5E6 | 2020-12-15 07:22:56 | product_cart | NaN |
| 163536 | 000199F1887AE5E6 | 2020-12-15 07:22:56 | product_page | NaN |
| 308595 | 000199F1887AE5E6 | 2020-12-15 07:22:56 | login | NaN |
| 97349 | 000199F1887AE5E6 | 2020-12-20 06:36:35 | product_cart | NaN |
| 355840 | 000199F1887AE5E6 | 2020-12-20 06:36:35 | login | NaN |
| 367197 | 000199F1887AE5E6 | 2020-12-21 02:11:23 | login | NaN |
| 202344 | 000199F1887AE5E6 | 2020-12-21 02:11:24 | product_page | NaN |
| 385742 | 0002499E372175C7 | 2020-12-22 03:49:52 | login | NaN |
Уже в начале отсортированной таблицы дубликатов видно, что для одного пользователя (user_id = 000199F1887AE5E6) в момент времени 2020-12-15 07:22:56 произошли одновременно 3 события: login, product_page, product_cart, что противоречит логике приложения даже с учётом быстродействия сетевого доступа.
Следует уведомить продуктовую команду о сбоях в системе логирования!
Отметим, однако, что посекундный учёт событий не входит в нашу задачу и не должен оказать существенного влияния на результаты АВ-теста. Поэтому продолжим изучение данных.
Рассмотрим данные в таблице marketing_events:
print('Общее количество маркетинговых акций:', len(marketing_events))
print('Количество уникальных названий акций:', marketing_events.name.nunique())
print('Количество уникальных регионов:', marketing_events.regions.nunique())
print('Состав регионов:', marketing_events.regions.unique())
print('Количество уникальных дат начала:', marketing_events.start_dt.nunique())
print('Количество уникальных дат начала:', marketing_events.finish_dt.nunique())
Общее количество маркетинговых акций: 14 Количество уникальных названий акций: 14 Количество уникальных регионов: 6 Состав регионов: ['EU, N.America' 'EU, CIS, APAC, N.America' 'N.America' 'APAC' 'EU, CIS, APAC' 'CIS'] Количество уникальных дат начала: 14 Количество уникальных дат начала: 14
Итак, мы имеем 14 уникальных маркетинговых акций. Регионы проведения части акций заданы перечислением. Для проверки влияния маркетинговых действий на АВ-тест необходимо выделить акции в регионе EU в период проведения теста.
Проверим полученные данные об АВ-тесте на соответствие техническому заданию:
# выделим пользователей из EU
eu_new_users = new_users.query('region == "EU"').copy()
print('Количество новых пользователей из EU:', len(eu_new_users))
# выделим из них пользователей, привлечённых по 21 декабря включительно
good_eu_new_users = eu_new_users.query('first_date < "2020-12-22"')
print('Количество пользователей из EU, привлечённых по 21 декабря:',
len(good_eu_new_users), '(',
round(len(good_eu_new_users) * 100 / len(eu_new_users), 2), '% )'
)
# выделим пользователей, привлечённых после 21 декабря
bad_eu_new_users = eu_new_users.query('first_date > "2020-12-21"')
print('Количество пользователей из EU, привлечённых по 21 декабря:',
len(bad_eu_new_users), '(',
round(len(bad_eu_new_users) * 100 / len(eu_new_users), 2), '% )'
)
Количество новых пользователей из EU: 46270 Количество пользователей из EU, привлечённых по 21 декабря: 42340 ( 91.51 % ) Количество пользователей из EU, привлечённых по 21 декабря: 3930 ( 8.49 % )
Итак, из 46270 привлечённых в EU пользователей 8.49% пришли в сервис после формального окончания набора новых пользователей. Проверим, попали ли они в тестовые группы:
(
bad_eu_new_users
.merge( # добавим информацию о тестах
participants, on='user_id', how='left'
)
.dropna() # удалим пользователей без тестов
.groupby(by=['ab_test', 'group'])
.agg({'user_id':'nunique'})
)
| user_id | ||
|---|---|---|
| ab_test | group | |
| interface_eu_test | A | 489 |
| B | 513 |
Излишне набранные пользователи попали в конкурирующий тест. Сообщить об этом его команде! Возможно, там срок набора дольше.
В дальнейшем мы можем работать только с пользователями, привлечёнными по 21 декабря включительно - good_eu_new_users.
Замечание: С учётом новых обстоятельств условие ТЗ об остановке набора новых пользователей для теста recommender_system_test 2020-12-21 соблюдено!
Проверим, в какие тесты и группы попали пользователи good_eu_new_users:
# выделим информацию по участникам тестов
eu_participants = (
good_eu_new_users
.merge( # добавим информацию о тестах
participants, on='user_id', how='left'
)
.dropna() # удалим пользователей без тестов
)
# сгруппируем по тестам и группам
eu_participant_groups = (
eu_participants
.groupby(by=['ab_test', 'group'], as_index=False)
.agg({'user_id':'nunique'})
.merge( # добавим общее количество пользователей теста
eu_participants
.groupby(by=['ab_test'], as_index=False)
.agg({'user_id':'nunique'})
.rename(columns={'user_id':'total_users'}),
on='ab_test', how='left'
)
)
eu_participant_groups['percent'] = (
(eu_participant_groups['user_id'] * 100 /
eu_participant_groups['total_users'])
.round(2)
)
eu_participant_groups
| ab_test | group | user_id | total_users | percent | |
|---|---|---|---|---|---|
| 0 | interface_eu_test | A | 5342 | 10565 | 50.56 |
| 1 | interface_eu_test | B | 5223 | 10565 | 49.44 |
| 2 | recommender_system_test | A | 3634 | 6351 | 57.22 |
| 3 | recommender_system_test | B | 2717 | 6351 | 42.78 |
В целом, в конкурирующий тест попало больше людей - 10565 человек против 6351 в целевом тесте.
В контрольную группу А целевого теста recommender_system_test попало 3634 человека, в группу В - 2717 человек. В целом группы можно считать сбалансированными (57.22% участников против 42.78%).
Выделим участников целевого теста и проверим, как они распределены по группам и сколько из них могли принять участие в двух тестах:
# выделяем целевых участников
target_eu_participants = (eu_participants
.query('ab_test == "recommender_system_test"'))
# выясним, есть ли пересечение групп
display(
target_eu_participants
.groupby(by='user_id').agg({'group':'count'})
.query('group > 1')
)
# добавим для них заново информацию о тестах
target_eu_participants = (
target_eu_participants
# уберём лишние столбцы
.drop(columns=['first_date', 'region', 'device', 'group', 'ab_test'])
# добавим вновь информацию о тестах
.merge(
eu_participants, on='user_id', how='left'
)
)
# выделим "двойных агентов"
double_test_perticipants = (
target_eu_participants
.groupby(by='user_id', as_index=False).agg({'ab_test':'count'})
.query('ab_test > 1')
.drop(columns=['ab_test']) # уберём лишние столбцы
.merge( # добавим вновь информацию о тестах
eu_participants, on='user_id', how='left'
)
)
# добавим количество двойных участников
eu_participant_groups = (
eu_participant_groups
.merge(
# сгруппируем по тестам и группам
double_test_perticipants
.groupby(by=['ab_test', 'group'], as_index=False)
.agg({'user_id':'nunique'})
.rename(columns={'user_id':'double_user_count'}),
on=['ab_test', 'group'], how='left'
)
)
# вычислим % от численности группы теста
eu_participant_groups['double_user_pct'] = (
(eu_participant_groups['double_user_count'] * 100 /
eu_participant_groups['user_id'])
.round(2)
)
eu_participant_groups
| group | |
|---|---|
| user_id |
| ab_test | group | user_id | total_users | percent | double_user_count | double_user_pct | |
|---|---|---|---|---|---|---|---|
| 0 | interface_eu_test | A | 5342 | 10565 | 50.56 | 819 | 15.33 |
| 1 | interface_eu_test | B | 5223 | 10565 | 49.44 | 783 | 14.99 |
| 2 | recommender_system_test | A | 3634 | 6351 | 57.22 | 921 | 25.34 |
| 3 | recommender_system_test | B | 2717 | 6351 | 42.78 | 681 | 25.06 |
Итак:
Само по себе это не должно сильно повлиять на тест recommender_system_test.
Поэтому логичное, с одной стороны, удаление двойных участников из целевого теста, вероятно, не приведёт к существенным изменениям результатов, а кроме того, сократит количество пользователей в тесте в среднем на 25%, что не позволить выполнить требование ТЗ по общей численности теста (6000 человек).
Однако нужно проверить как распределились устройства для всех участников теста recommender_system_test и для двойных участников:
# снова удалим информацию о втором тесте
target_eu_participants = (eu_participants
.query('ab_test == "recommender_system_test"'))
# посчитаем количество устройств в группах
target_eu_participant_devices = (
target_eu_participants
.groupby(by=['group', 'device']).agg({'user_id':'count'})
.rename(columns={'user_id':'participant_count'})
# добавим количества устройств у двойных участников
.join(
double_test_perticipants
.groupby(by=['group', 'device']).agg({'user_id':'count'})
.rename(columns={'user_id':'double_participant_count'}),
how='left'
)
)
# вычислим %
target_eu_participant_devices['pct'] = (
(target_eu_participant_devices['double_participant_count'] * 100 /
target_eu_participant_devices['participant_count'])
.round(2)
)
target_eu_participant_devices
| participant_count | double_participant_count | pct | ||
|---|---|---|---|---|
| group | device | |||
| A | Android | 1590 | 766 | 48.18 |
| Mac | 354 | 188 | 53.11 | |
| PC | 964 | 461 | 47.82 | |
| iPhone | 726 | 325 | 44.77 | |
| B | Android | 1228 | 612 | 49.84 |
| Mac | 250 | 174 | 69.60 | |
| PC | 657 | 381 | 57.99 | |
| iPhone | 582 | 297 | 51.03 |
Из полученной таблицы следует, что по устройствам двойные пользователи распределены по группам не так равномерно, как по общему количеству. Это может оказать некоторое воздействие на результат. Попробуем учесть это при оценке результатов АВ-теста.
Итак, общее количество участников теста recommender_system_test равно 6351, что удовлетворяет требованиям технического задания с оговорками, сделанными выше.
len(target_eu_participants) / len(good_eu_new_users)
0.15
Доля участников от привлечённых в EU пользователей составляет 15%, что удовлетворяет требованиям ТЗ.
Оценка соответствия проведённого АВ-теста условиям тезнического задания завершена.
Выделим события, совершённые участниками целевого АВ-теста:
target_eu_participant_events = (
target_eu_participants
# удалим лишние данные
.drop(columns=['first_date', 'region', 'device', 'ab_test'])
.merge(
events, on='user_id', how='left'
)
)
# проверим, у всех ли участников были события
target_eu_participant_events.event_name.isna().sum()
2870
target_eu_participant_events.event_name.isna().sum() / len(target_eu_participants)
0.451897339001732
Оказывается 2870 из 6351 участников теста (свыше 45%) не совершили за указанный период ни одного события!
Уведомить разработчиков: Налицо наличие серьёзной ошибки в системе логирования событий!
Это может повлиять на результаты теста, но придумать недостающие данные мы не можем. Посмотрим, как пользователи без событий распределились между группами теста и по устройствам:
# выделим пользователей без событий
target_eu_participant_no_events = (
target_eu_participant_events[
target_eu_participant_events['event_name'].isna()
]
)
# посчитаем по группам теста
target_eu_participant_no_events_groups = (
target_eu_participant_no_events
.groupby(by='group').agg({'user_id':'count'})
.rename(columns={'user_id':'no_event_users'})
.join(
target_eu_participants.groupby(by='group').agg({'user_id':'count'}),
how='left'
)
.rename(columns={'user_id':'total_users'})
)
# оценим % пользователей без событий
target_eu_participant_no_events_groups['pct'] = (
(target_eu_participant_no_events_groups['no_event_users'] * 100 /
target_eu_participant_no_events_groups['total_users'])
.round(2)
)
# оценим количество пользователей с событиями
target_eu_participant_no_events_groups['delta'] = (
(target_eu_participant_no_events_groups['total_users'] -
target_eu_participant_no_events_groups['no_event_users'])
)
target_eu_participant_no_events_groups
| no_event_users | total_users | pct | delta | |
|---|---|---|---|---|
| group | ||||
| A | 1030 | 3634 | 28.34 | 2604 |
| B | 1840 | 2717 | 67.72 | 877 |
Итак, мы имеем свыше 28% от численности группы А и свыше 67% от численности группы В без зарегистрированных в логе событий. Такое большое количество "мёртвых душ" может серьёзно исказить метрики, поскольку они будут учтены в знаменателе целевых метрик, но не учтены в числителе. При этом, с учётом того, что тестовая группа и так меньше контрольной, существует риск занижения метрик в тестовой группе.
В этой связи представляется целесообразным убрать таких пользователей из лога. При этом снизится мощность теста, однако сохранится статистическая значимость - в группе сохранится 877 пользователей с активностями, свыше 33% от численности группы А:
(
target_eu_participant_no_events_groups.loc['B','delta'] /
target_eu_participant_no_events_groups.loc['A','delta']
)
0.33678955453149
Вероятная причина наличия "мёртвых душ" - ошибки и сбои в системе логирования действий пользователей. Для поиска и локализации ошибок командой определим дни, в которые были привлечены эти пользователи:
(
target_eu_participant_no_events
.drop(columns=['group', 'event_dt', 'event_name', 'details'])
.merge(
new_users, on='user_id', how='left'
)
.sort_values(by='first_date')
['first_date'].unique()
)
array(['2020-12-07T00:00:00.000000000', '2020-12-08T00:00:00.000000000',
'2020-12-09T00:00:00.000000000', '2020-12-10T00:00:00.000000000',
'2020-12-11T00:00:00.000000000', '2020-12-12T00:00:00.000000000',
'2020-12-13T00:00:00.000000000', '2020-12-14T00:00:00.000000000',
'2020-12-15T00:00:00.000000000', '2020-12-16T00:00:00.000000000',
'2020-12-17T00:00:00.000000000', '2020-12-18T00:00:00.000000000',
'2020-12-19T00:00:00.000000000', '2020-12-20T00:00:00.000000000',
'2020-12-21T00:00:00.000000000'], dtype='datetime64[ns]')
Очевидно, сбои в логировании действий происходили на протяжении всего периода привлечения.
Удалим "мёртвые души" из участников теста:
target_eu_participant_events = (
target_eu_participant_events.dropna(axis=0, thresh=4)
)
target_eu_participant_events
| user_id | group | event_dt | event_name | details | |
|---|---|---|---|---|---|
| 0 | D72A72121175D8BE | A | 2020-12-07 21:52:10 | product_page | NaN |
| 1 | D72A72121175D8BE | A | 2020-12-07 21:52:07 | login | NaN |
| 3 | DD4352CDCF8C3D57 | B | 2020-12-07 15:32:54 | product_page | NaN |
| 4 | DD4352CDCF8C3D57 | B | 2020-12-08 08:29:31 | product_page | NaN |
| 5 | DD4352CDCF8C3D57 | B | 2020-12-10 18:18:27 | product_page | NaN |
| ... | ... | ... | ... | ... | ... |
| 26284 | 0416B34D35C8C8B8 | A | 2020-12-21 22:28:29 | product_page | NaN |
| 26285 | 0416B34D35C8C8B8 | A | 2020-12-24 09:12:51 | product_page | NaN |
| 26286 | 0416B34D35C8C8B8 | A | 2020-12-20 20:58:25 | login | NaN |
| 26287 | 0416B34D35C8C8B8 | A | 2020-12-21 22:28:29 | login | NaN |
| 26288 | 0416B34D35C8C8B8 | A | 2020-12-24 09:12:49 | login | NaN |
23420 rows × 5 columns
Для оценки возможного влияния маркетинговых активностей на результаты теста выделим те из них, которые проводились в регионе EU во время проведения теста. За дату окончания АВ-теста теперь можно принять дату последнего события в таблице target_eu_participant_events. Условием, что акция могла повлиять на тест является тот факт, что она началась до конца теста и закончилась после его начала:
# начало теста по логам
ab_test_start_dt = target_eu_participant_events.event_dt.min()
# конец теста по логам
ab_test_end_dt = target_eu_participant_events.event_dt.max()
(
marketing_events[marketing_events['regions'].str.contains('EU')]
.query('start_dt <= @ab_test_end_dt and finish_dt >= @ab_test_start_dt')
)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
Итак, только новогодняя маркетинговая акция из календаря могла повлиять на результаты теста.
Проверим, одинаково ли распределено в выборках количество событий в разрезе групп:
# визуализируем распределение
fig = px.histogram(target_eu_participant_events, x="event_name", color="group")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение количества событий разных типов в разрезе групп',
xaxis_title_text='События',
yaxis_title_text='Количество'
)
fig.show()
Гистограмма распределения событий по типам в разрезе групп показывает, что события распределены между группами схожим образом.
Посчитаем общее количество событий на пользователя для каждой из групп:
# посчитаем, сколько событий совершил каждый пользователь каждой группы
events_by_user = (
target_eu_participant_events
.groupby(by=['group', 'user_id'], as_index=False)
.agg({'event_name':'count'})
.rename(columns={'event_name':'event_count'})
)
# визуализируем распределение
fig = px.histogram(events_by_user, x="event_count", color="group")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение количества событий на пользователя в разрезе групп',
xaxis_title_text='Количество событий',
yaxis_title_text='Частота'
)
fig.show()
Гистограмма распределения количества событий на пользователя в разрезе групп показывает, что распределения для разных групп схожи, большая часть пользователей и в группе А и в группе В совершили от 2 до 12 событий за период теста, при этом распределение тестовой группы В чуть сильнее смещено влево.
Построим распределение количества событий по времени в разрезе групп:
# визуализируем распределение
fig = px.histogram(target_eu_participant_events, x="event_dt", color="group")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение количества событий по времени в разразе групп',
xaxis_title_text='Дата и время',
yaxis_title_text='Количество'
)
fig.show()
Мы видим, что количество событий по времени для групп А и В также распределено неодинаково:
Визуализируем воронку событий. Ранее мы описывали стадии естественной для сервиса воронки следующим образом: login -> product_page -> product_cart -> purchase.
Отметим, что события в данной воронке упорядочены не лексикографически. Для правильного отображения воронки события нужно пронумеровать и отсортировать по номерам:
# зададим нумерацию событий в воронке
event_list = target_eu_participant_events.event_name.unique()
event_num_list = []
for evt in event_list:
if evt == 'login':
event_num_list.append(1)
elif evt == 'product_page':
event_num_list.append(2)
elif evt == 'product_cart':
event_num_list.append(3)
elif evt == 'purchase':
event_num_list.append(4)
else:
# исключительная ситуация, если в воронке прявятся новые события,
# потребуется изменить код
event_num_list.append(-1)
# соберём словарь нумерации для воронки
event_num = pd.DataFrame(dict(number=event_num_list, event_name=event_list))
event_num
| number | event_name | |
|---|---|---|
| 0 | 2 | product_page |
| 1 | 1 | login |
| 2 | 4 | purchase |
| 3 | 3 | product_cart |
# построим воронку
funnel = (
target_eu_participant_events
.groupby(by=['event_name', 'group'], as_index=False)
.agg({'user_id':'nunique'})
.merge(
event_num, on='event_name', how='left'
)
.sort_values(by=['number', 'group'])
)
display(funnel)
# визуализируем воронку
fig = go.Figure()
groups = funnel.group.unique()
for g in groups:
fig.add_trace(go.Funnel(
name=g,
y=list(funnel.query('group == @g')['event_name']),
x=list(funnel.query('group == @g')['user_id']),
textinfo = "value+percent initial+percent previous"
))
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Воронка событий АВ-теста',
yaxis_title_text='Событие'
)
fig.show()
| event_name | group | user_id | number | |
|---|---|---|---|---|
| 0 | login | A | 2604 | 1 |
| 1 | login | B | 877 | 1 |
| 4 | product_page | A | 1685 | 2 |
| 5 | product_page | B | 493 | 2 |
| 2 | product_cart | A | 782 | 3 |
| 3 | product_cart | B | 244 | 3 |
| 6 | purchase | A | 833 | 4 |
| 7 | purchase | B | 249 | 4 |
На основании построенной воронки событий опишем, как меняется конверсия в выборках на разных этапах:
login представлено 2604 (100%) пользователей.product_page перешли 1685 пользователей (65% от предыдущего этапа).product_cart попали 782 человека (46% от предыдущего этапа и 30% от общего количества группы А).purchase) совершили 833 пользователя (107% от предыдущего этапа и 32% от исходного количества).login представлено 877 (100%) пользователей.product_page перешли 493 пользователея (56% от предыдущего этапа).product_cart попали 244 человека (49% от предыдущего этапа и 28% от общего количества группы В).purchase) совершили 249 пользователя (102% от предыдущего этапа и 28% от исходного количества).Из полученного сравнения можно заключить, что в целом за период наблюдений конверсия тестовой группы В на каждом шаге воронки ниже по сравнению с контрольной группой А.
Более высокая конверсия покупок по сравнению с показами корзины объясняется, вероятно, наличием возможности быстрых покупок со страницы товара, минуя корзину.
Возможно, на такие низкие результаты тестовой группы оказало влияние удержание пользователей. Посмотрим, в какие даты приходили пользователи, и в какие прекращяли пользование сервисом.
# последние даты пользования и даты привлечения
target_retention = (
target_eu_participant_events
.groupby(by=['group', 'user_id'], as_index=False)
.agg({'event_dt':'max'})
.rename(columns={'event_dt':'last_event_dt'})
.merge(
new_users[['user_id', 'first_date']], on='user_id', how='left'
)
)
# визуализируем привлечение
fig = px.histogram(target_retention, x="first_date", color="group")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение дней привлечения в разразе групп',
xaxis_title_text='Дата',
yaxis_title_text='Количество'
)
fig.show()
# визуализируем отток
fig = px.histogram(target_retention, x="last_event_dt", color="group")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение дней оттока в разразе групп',
xaxis_title_text='Дата и время',
yaxis_title_text='Количество'
)
fig.show()
Сравнение гистограмм распределений дат привлечения и оттока показывают, что распределения привлечения и оттока между группами различаются:
В процессе EDA нами были проведены оценка соответствия полученных данных АВ-тестирования техническому заданию, исследование распределений и воронки событий.
По результатам могут быть сделаны следующие выводы:
login представлено 2604 (100%) пользователей.product_page перешли 1685 пользователей (65% от предыдущего этапа).product_cart попали 782 человека (46% от предыдущего этапа и 30% от общего количества группы А).purchase) совершили 833 пользователя (107% от предыдущего этапа и 32% от исходного количества).login представлено 877 (100%) пользователей.product_page перешли 493 пользователея (56% от предыдущего этапа).product_cart попали 244 человека (49% от предыдущего этапа и 28% от общего количества группы В).purchase) совершили 249 пользователя (102% от предыдущего этапа и 28% от исходного количества).Кроме того, выявлены сбои в системе логирования событий, на которые указывают:
Отметим, что большое количество "мёртвых душ" может серьёзно исказить метрики и повлиять на результаты теста, поскольку они будут учтены в знаменателе целевых метрик, но не учтены в числителе. При этом, с учётом того, что тестовая группа и так меньше контрольной, существует риск занижения метрик в тестовой группе.
Оценим соответствие достигнутых результатов АВ-теста ожидаемому эффекту: за 14 дней с момента регистрации пользователи тестовой группы В покажут улучшение каждой метрики не менее, чем на 10%, по сравнению с контрольной группой А:
product_page,product_cart,purchase.Конверсии будем считать по формулам:
$$ CR_{pp} = \frac {Users_{pp}}{Users_{total}}, $$$$ CR_{pc} = \frac {Users_{pc}}{Users_{total}}, $$$$ CR_{p} = \frac {Users_{p}}{Users_{total}}, $$где:
product_page за некоторый промежуток времени;product_cart за некоторый промежуток времени;purchase за некоторый промежуток времени;Для решения задачи нам необходимо:
1. Определим дневные лайфтаймы событий для каждого пользователя с момента регистрации:
# добавим даты привлечения
target_eu_participant_events = (
target_eu_participant_events
# добавим даты привлечения
.merge(
new_users[['user_id', 'first_date']],
on='user_id', how='left'
)
)
# посчитаем дневные лайфтаймы
target_eu_participant_events['lifetime'] = (
target_eu_participant_events['event_dt'] -
target_eu_participant_events['first_date']
).dt.days
target_eu_participant_events.sample(10)
| user_id | group | event_dt | event_name | details | first_date | lifetime | |
|---|---|---|---|---|---|---|---|
| 4943 | 4D2B0FBB9E528D60 | A | 2020-12-15 09:18:40 | product_page | NaN | 2020-12-14 | 1 |
| 1511 | 1AA90C1AD5727610 | B | 2020-12-07 17:17:15 | login | NaN | 2020-12-07 | 0 |
| 13874 | 60E68A4F33951169 | A | 2020-12-20 00:59:28 | login | NaN | 2020-12-16 | 4 |
| 21336 | 6F70F06DD209676B | A | 2020-12-26 06:11:19 | product_page | NaN | 2020-12-13 | 13 |
| 20087 | 72742C5F312A1FEC | B | 2020-12-20 14:13:18 | login | NaN | 2020-12-19 | 1 |
| 4157 | 79913E8816E3DA5D | A | 2020-12-17 12:11:29 | product_cart | NaN | 2020-12-14 | 3 |
| 3395 | 9DB76819BAB65ED6 | A | 2020-12-25 06:36:15 | login | NaN | 2020-12-14 | 11 |
| 8660 | 8EB70A4733EED0BA | A | 2020-12-21 05:41:41 | purchase | 4.99 | 2020-12-08 | 13 |
| 15522 | 6687D5BCA6CD3141 | A | 2020-12-18 23:31:46 | product_cart | NaN | 2020-12-17 | 1 |
| 4865 | B85F36F6515DAB84 | A | 2020-12-16 19:05:05 | purchase | 4.99 | 2020-12-14 | 2 |
Взглянем на распределения событий по лайфтаймам для каждой группы тестирования:
# визуализируем распределение событий группы А
fig = px.histogram(target_eu_participant_events.query('group == "A"'),
x="lifetime", color="event_name")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение событий группы А по лайфтаймам',
xaxis_title_text='Лайфтайм',
yaxis_title_text='Количество'
)
fig.show()
В группе А:
product_page распределено по пуассоновскому закону, основная доля этих событий произошла в первые 17 дней с момента привлечения, единичные события происходили вплоть до 23 лайфтайма;product_cart распределено по пуассоновскому закону, основная доля этих событий произошла в первые 16 дней с момента привлечения, единичные события происходили вплоть до 21 лайфтайма;purchase распределено по пуассоновскому закону, основная доля этих событий произошла в первые 17 дней с момента привлечения, единичные события происходили вплоть до 21 лайфтайма.# визуализируем распределение событий группы В
fig = px.histogram(target_eu_participant_events.query('group == "B"'),
x="lifetime", color="event_name")
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Распределение событий группы В по лайфтаймам',
xaxis_title_text='Лайфтайм',
yaxis_title_text='Количество'
)
fig.show()
В группе В:
product_page распределено по закону, приближенному к пуассоновскому, основная доля этих событий произошла в первые 21 день с момента привлечения;product_cart распределено по закону, приближенному к пуассоновскому, основная доля этих событий произошла в первые 21 день с момента привлечения;purchase распределено по закону, приближенному к пуассоновскому, вплоть до 23 лайфтайма.Промежуточный вывод: целевые события воронки для группы А расположены более компактно к началу тестирования, что может свидетельствовать о более высокой конверсии по сравнению с группой В в условиях временных ограничений.
2. Ограничим лог событий лайфтаймами с 0 по 13 (14 дней):
target_events_14 = (
target_eu_participant_events.query('lifetime < 14')
)
target_events_14
| user_id | group | event_dt | event_name | details | first_date | lifetime | |
|---|---|---|---|---|---|---|---|
| 0 | D72A72121175D8BE | A | 2020-12-07 21:52:10 | product_page | NaN | 2020-12-07 | 0 |
| 1 | D72A72121175D8BE | A | 2020-12-07 21:52:07 | login | NaN | 2020-12-07 | 0 |
| 2 | DD4352CDCF8C3D57 | B | 2020-12-07 15:32:54 | product_page | NaN | 2020-12-07 | 0 |
| 3 | DD4352CDCF8C3D57 | B | 2020-12-08 08:29:31 | product_page | NaN | 2020-12-07 | 1 |
| 4 | DD4352CDCF8C3D57 | B | 2020-12-10 18:18:27 | product_page | NaN | 2020-12-07 | 3 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 23415 | 0416B34D35C8C8B8 | A | 2020-12-21 22:28:29 | product_page | NaN | 2020-12-20 | 1 |
| 23416 | 0416B34D35C8C8B8 | A | 2020-12-24 09:12:51 | product_page | NaN | 2020-12-20 | 4 |
| 23417 | 0416B34D35C8C8B8 | A | 2020-12-20 20:58:25 | login | NaN | 2020-12-20 | 0 |
| 23418 | 0416B34D35C8C8B8 | A | 2020-12-21 22:28:29 | login | NaN | 2020-12-20 | 1 |
| 23419 | 0416B34D35C8C8B8 | A | 2020-12-24 09:12:49 | login | NaN | 2020-12-20 | 4 |
22620 rows × 7 columns
Итак у нас в логе осталось 22620 событий, совершённых пользователями групп А и В в первые 14 дней с момента привлечения.
3. Построим воронку событий, посчитаем требуемые конверсии для групп А и В:
# построим воронку
funnel_14 = (
target_events_14
.groupby(by=['event_name', 'group'], as_index=False)
.agg({'user_id':'nunique'})
.merge(
event_num, on='event_name', how='left'
)
.sort_values(by=['number', 'group'])
)
display(funnel_14)
# визуализируем воронку
fig = go.Figure()
groups = funnel_14.group.unique()
for g in groups:
fig.add_trace(go.Funnel(
name=g,
y=list(funnel_14.query('group == @g')['event_name']),
x=list(funnel_14.query('group == @g')['user_id']),
textinfo = "value+percent initial"
))
# зададим названия гистограммы и осей
fig.update_layout(
title_text='Воронка событий АВ-теста за первые 14 дней',
yaxis_title_text='Событие'
)
fig.show()
| event_name | group | user_id | number | |
|---|---|---|---|---|
| 0 | login | A | 2604 | 1 |
| 1 | login | B | 876 | 1 |
| 4 | product_page | A | 1685 | 2 |
| 5 | product_page | B | 493 | 2 |
| 2 | product_cart | A | 782 | 3 |
| 3 | product_cart | B | 244 | 3 |
| 6 | purchase | A | 833 | 4 |
| 7 | purchase | B | 249 | 4 |
Итак, мы видим, что по имеющимся данным о событиях конверсии всех этапов для тестовой группы В ниже, чем в контрольной группе А.
Предварительно можно утверждать, что ожидаемый эффект не достигнут.
4. Оценим разницу в конверсиях:
# сгруппируем данные о событиях
funnel_14_comparison = (
# посчитаем количество пользователей группы А по этапам воронки
funnel_14.query('group == "A"')
.merge( # добавим общую численность группы А
target_events_14
.groupby(by='group', as_index=False).agg({'user_id':'nunique'})
.rename(columns={'user_id':"total_users"}),
on='group', how='left'
)
.rename(columns={'user_id':'stage_users_a', 'total_users':'total_users_a'})
.drop(columns=['group', 'number'])
.merge( # добавим количество пользователей группы В по этапам воронки
funnel_14.query('group == "B"')
.merge( # добавим общую численность группы В
target_events_14
.groupby(by='group', as_index=False).agg({'user_id':'nunique'})
.rename(columns={'user_id':"total_users"}),
on='group', how='left'
)
.rename(columns={'user_id':'stage_users_b', 'total_users':'total_users_b'})
.drop(columns=['group', 'number']),
on='event_name', how='left'
)
)
# посчитаем отношение конверсий по этапам
funnel_14_comparison['ratio'] = (
(funnel_14_comparison['stage_users_b'] * funnel_14_comparison['total_users_a']) /
(funnel_14_comparison['total_users_b'] * funnel_14_comparison['stage_users_a'])
).round(2)
funnel_14_comparison
| event_name | stage_users_a | total_users_a | stage_users_b | total_users_b | ratio | |
|---|---|---|---|---|---|---|
| 0 | login | 2604 | 2604 | 876 | 877 | 1.00 |
| 1 | product_page | 1685 | 2604 | 493 | 877 | 0.87 |
| 2 | product_cart | 782 | 2604 | 244 | 877 | 0.93 |
| 3 | purchase | 833 | 2604 | 249 | 877 | 0.89 |
Анализируя полученную таблицу, можно заметить, что:
product_page ниже конверсии группы А на 13%.product_cart ниже конверсии группы А на 7%.purchase ниже конверсии группы А на 11%.Вывод: ожидаемый эффект теста не достигнут.
Оценим статистическую разницу конверсий на разных этапах воронки с помощью теста на равенство долей. Реализуем тест в виде функции:
# определение функции статистического критерия на равенство долей
# =========================================================================
def prop_difference_criteria(
df, # датафрейм с данными пропорций
part_col, # числитель пропорции
full_col, # знаменатель пропорции
alpha=.05 # критический уровень статистической значимости
):
alpha = alpha
successes = np.array(df[part_col])
trials = np.array(df[full_col])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) *
(1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
# считаем вероятность того, что статистика "уехала" от 0 на заданную величину
# или больше, с использованием кумулятивной функции распределения (для
# нормального распределения)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
product_page для групп А и В¶Гипотеза состоит в том, что конверсии в этап product_page для групп А и В различаются. Сформулируем основную статистическую гипотезу и альтернативу:
product_page для групп А и В одинаковы;product_page для групп А и В различаются.Для проверки гипотезы нам нужны общее количество пользователей, для каждой группы, а также количество тех из них, которые совершили целевое действие. Количество пользователей, совершавших события, хранится в таблице funnel_14, к которой необходимо добавить общую численность групп для запуска функции prop_difference_criteria:
# сформируем данные для теста
test_data = (
funnel_14
.merge(
target_events_14
.groupby(by='group', as_index=False).agg({'user_id':'nunique'})
.rename(columns={'user_id':"total_users"}),
on='group', how='left'
)
.drop(columns=['number'])
.query('event_name == "product_page"')
)
display(test_data)
# запустим тест на равенство долей
prop_difference_criteria(test_data, 'user_id', 'total_users')
| event_name | group | user_id | total_users | |
|---|---|---|---|---|
| 2 | product_page | A | 1685 | 2604 |
| 3 | product_page | B | 493 | 877 |
p-значение: 6.942739359416805e-06 Отвергаем нулевую гипотезу: между долями есть значимая разница
На имеющихся данных при заданном критическом уровне статистической значимости 0.05 есть основания считать конверсии в этап product_page для групп А и В различными.
product_cart для групп А и В¶Гипотеза состоит в том, что конверсии в этап product_cart для групп А и В различаются. Сформулируем основную статистическую гипотезу и альтернативу:
product_cart для групп А и В одинаковы;product_cart для групп А и В различаются.Сформируем тестовые данные и запустим функцию prop_difference_criteria:
# сформируем данные для теста
test_data = (
funnel_14
.merge(
target_events_14
.groupby(by='group', as_index=False).agg({'user_id':'nunique'})
.rename(columns={'user_id':"total_users"}),
on='group', how='left'
)
.drop(columns=['number'])
.query('event_name == "product_cart"')
)
display(test_data)
# запустим тест на равенство долей
prop_difference_criteria(test_data, 'user_id', 'total_users')
| event_name | group | user_id | total_users | |
|---|---|---|---|---|
| 4 | product_cart | A | 782 | 2604 |
| 5 | product_cart | B | 244 | 877 |
p-значение: 0.21469192029582396 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
На имеющихся данных при заданном критическом уровне статистической значимости 0.05 нет оснований считать конверсии в этап product_cart для групп А и В различными.
purchase для групп А и В¶Гипотеза состоит в том, что конверсии в этап purchase для групп А и В различаются. Сформулируем основную статистическую гипотезу и альтернативу:
purchase для групп А и В одинаковы;purchase для групп А и В различаются.Сформируем тестовые данные и запустим функцию prop_difference_criteria:
# сформируем данные для теста
test_data = (
funnel_14
.merge(
target_events_14
.groupby(by='group', as_index=False).agg({'user_id':'nunique'})
.rename(columns={'user_id':"total_users"}),
on='group', how='left'
)
.drop(columns=['number'])
.query('event_name == "purchase"')
)
display(test_data)
# запустим тест на равенство долей
prop_difference_criteria(test_data, 'user_id', 'total_users')
| event_name | group | user_id | total_users | |
|---|---|---|---|---|
| 6 | purchase | A | 833 | 2604 |
| 7 | purchase | B | 249 | 877 |
p-значение: 0.04652482738393027 Отвергаем нулевую гипотезу: между долями есть значимая разница
На имеющихся данных при заданном критическом уровне статистической значимости 0.05 есть основания считать конверсии в этап purchase для групп А и В различными.
В ходе оценки результатов АВ-теста мы построили воронку событий на глубину 14 лайфтаймов с момента привлечения пользователя и установили, что:
product_page ниже конверсии группы А на 13%.product_cart ниже конверсии группы А на 7%.purchase ниже конверсии группы А на 11%.Вывод: ожидаемый эффект АВ-теста не достигнут.
В подтверждение этого мы проверили три статистических гипотезы:
product_page для групп А и В различаются;product_cart для групп А и В различаются;purchase для групп А и В различаются.Для проверки использован критерий равенства долей (z-test):
product_page для групп А и В различными.product_cart для групп А и В различными.purchase для групп А и В различными.В процессе анализа проведённого АВ-теста мы изучили (EDA) предоставленные данные, построили целевую воронку событий и посчитали и сравнили между собой конверсии в различные этапы воронки групп А и В.
Подробные выводы по этапам исследования представлены в соответствующих разделах.
Резюмируя данные выводы, целесообразно признать собранные данные не соответствующими техническому заданию на АВ-тест. Основной причиной с высокой долей вероятности является некорректная работа логирования событий - нам пришлось удалить из выборки свыше 28% от исходной численности группы А и свыше 67% от численности группы В.
Вместе с тем, оставшиеся данные сохранили статистическую значимость, а проведённое статистическое сравнение целевых метрик между группами А и В позволяет считать ожидаемый эффект АВ-теста не достигнутым.
Ввиду того, что основная доля событий для пользователей распределена в течение первых 14-17 лайфтаймов, досрочное прекращение теста могло оказать незначительное влияние только на пользователей, пришедших в конце третьей недели привлечения, что не должно отразиться на статистической значимости результатов теста в целом.
По результатам анализа можно сформулировать следующие рекомендации: